// (c) Copyright 2013-2014 Stephen Gold <sgold@sonic.net>
// Distributed under the terms of the GNU General Public License

/*
 This file is part of the Bats Game.

 The Bats Game is free software: you can redistribute it and/or modify
 it under the terms of the GNU General Public License as published by the
 Free Software Foundation, either version 3 of the License, or (at your
 option) any later version.

 The Bats Game is distributed in the hope that it will be useful, but
 WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
 or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License
 for more details.

 You should have received a copy of the GNU General Public License
 along with the Bats Game.  If not, see <http://www.gnu.org/licenses/>.
 */
package bats.scene;

import bats.Bats;
import bats.BatsPrinter;
import bats.SpatialProperties;
import bats.control.BatsControl;
import bats.ui.HeadsUpDisplay;
import bats.ui.PoseHud;
import com.jme3.audio.AudioNode;
import com.jme3.math.FastMath;
import com.jme3.math.Vector2f;
import com.jme3.math.Vector3f;
import com.jme3.renderer.ViewPort;
import com.jme3.scene.Node;
import com.jme3.scene.SceneGraphVisitor;
import com.jme3.scene.Spatial;
import com.jme3.scene.control.Control;
import java.util.logging.Level;
import java.util.logging.Logger;
import jme3utilities.MySpatial;
import jme3utilities.MyString;
import jme3utilities.NameGenerator;
import jme3utilities.SimpleControl;

/**
 * A simple control to manage a complete scene, including a scenario, a world, a
 * point of view, a name generator, water features, and graphical indicators.
 * <p>
 * Simulation state lives here. To pause or resume simulation, invoke
 * setEnabled(). To disable the scene completely, invoke detach().
 *
 * @author Stephen Gold <sgold@sonic.net>
 */
public class SceneNode
        extends SimpleControl {
    // *************************************************************************
    // constants

    /**
     * maximum time between updates (in seconds, &gt;0) important to
     * LaunchControl
     */
    final public static float maxSimInterval = 0.1f;
    /**
     * rate for simulation and animations when the control is disabled (paused)
     * <p>
     * Zero would cause SimpleApplication to disable all controls, which would
     * create problems.
     */
    final private static float pauseSpeed = Float.MIN_VALUE;
    /**
     * message logger for this class
     */
    final private static Logger logger =
            Logger.getLogger(SceneNode.class.getName());
    // *************************************************************************
    // fields
    /**
     * application instance which owns this scene: set by initialize()
     */
    private Bats bats = null;
    /**
     * true when configured for world-building, false otherwise
     */
    private boolean buildModeFlag = false;
    /**
     * true prior to the first update, false thereafter
     */
    private boolean firstUpdate = true;
    /**
     * true when configured for simulation, false otherwise
     */
    private boolean simModeFlag = false;
    /**
     * rate for simulation and animations when the control is enabled (standard
     * speed=1)
     */
    private float resumeSpeed = 1f;
    /**
     * factor for converting world coordinates into meters (meters per world
     * unit, &gt;0)
     * <p>
     * Originally I planned to use a world unit of 1 meter, but I've heard that
     * jBullet works best with objects of a particular size, so a world unit of
     * 1 meter may be suboptimal.
     */
    private float scaleFactor = 1f;
    /**
     * elapsed simulation time (in seconds)
     */
    private double simTime = 0f;
    /**
     * indicator subtree: set by initialize()
     */
    private IndicatorsNode indicators = null;
    /**
     * lights, shadows, sky, and background of this scene: set by initialize()
     */
    private Lighting lighting = null;
    /**
     * name generator for scenes
     */
    private static NameGenerator sceneNameGenerator = null;
    /**
     * name generator for spatials in this scene: set by constructor
     */
    final private NameGenerator spatialNameGenerator;
    /**
     * name of the scenario used to initialize this scene: set by constructor
     */
    final private String scenarioName;
    /**
     * POV for this scene: set by initialize()
     */
    private PointOfView pov = null;
    /**
     * view port for this scene: set by constructor
     */
    final private ViewPort viewPort;
    /**
     * water features in this scene: set by initialize()
     */
    private Water water = null;
    /**
     * lit/physical objects subtree: set by initialize()
     */
    private WorldNode world = null;
    // *************************************************************************
    // constructors

    /**
     * Instantiate a disabled (paused) and unattached scene for a specified
     * scenario and viewPort. The first method invoked should be initialize().
     *
     * @param bats the application instance (not null)
     * @param scenarioName (not null)
     * @param viewPort (disabled, not null)
     */
    public SceneNode(Bats bats, String scenarioName, ViewPort viewPort) {
        super();

        assert bats != null;
        assert scenarioName != null;
        assert viewPort != null;
        assert !viewPort.isEnabled();

        this.bats = bats;
        this.scenarioName = scenarioName;
        this.viewPort = viewPort;
        spatialNameGenerator = new NameGenerator();

        super.setEnabled(false);
        assert !isAttached();
    }
    // *************************************************************************
    // new methods exposed

    /**
     * Attach this scene's top node to the application's scene graph. Only one
     * scene can be attached at a time.
     *
     * @see #detach()
     */
    public void attach() {
        assert !isEnabled();
        assert !isAttached();

        Node root = bats.getRootNode();
        assert root.getChildren().isEmpty() : root.getChildren();
        int count = root.attachChild(spatial);
        assert count == 1 : count;

        pov.setEnabled(true);
        viewPort.setEnabled(true);
        world.setEnabled(true);

        assert isAttached();
    }

    /**
     * Attach an orphan spatial as a child of this scene's top node.
     *
     * @param child which spatial to add (not null)
     */
    public void attachChild(Spatial child) {
        assert child != null;
        assert MySpatial.isOrphan(child);

        getNode().attachChild(child);
    }

    /**
     * Test whether a spatial is contained in this scene.
     *
     * @param spatial which spatial to test (or null)
     * @return true if it's contained in the scene, otherwise false
     */
    public boolean contains(Spatial spatial) {
        if (spatial == null) {
            return false;
        }
        boolean result = spatial.hasAncestor(getNode());
        return result;
    }

    /**
     * Cleanly delete a spatial from this scene, along with its children,
     * controls, water features, and lights.
     *
     * @param spatial which spatial to delete (not null)
     */
    public void deleteSpatial(Spatial spatial) {
        assert contains(spatial) : spatial;

        deleteSpatial(spatial, null);
    }

    /**
     * Cleanly delete a spatial from this scene, along with its children,
     * controls, water features, and lights, with the possible exception of one
     * specified control.
     *
     * @param spatial which spatial to delete (not null)
     * @param skipControl which control to not delete (or null to delete all)
     */
    public void deleteSpatial(Spatial spatial, Object skipControl) {
        assert contains(spatial) : spatial;

        logger.log(Level.INFO, "deleting {0}", spatial.getName());

        deleteSpatialControls(spatial, skipControl);

        indicators.deleteIndicator(spatial);
        lighting.deleteSpatialLights(spatial);
        pov.deleteSpatial(spatial);

        if (spatial instanceof Node) {
            Node node = (Node) spatial;
            /*
             * Delete the node's children.
             */
            for (Spatial child : node.getChildren()) {
                deleteSpatial(child, skipControl);
            }

            if (node instanceof AudioNode) {
                AudioNode audioNode = (AudioNode) node;
                /*
                 * Stop the audio.
                 */
                audioNode.stop();
            }

            Spatial feature = water.getWaterFeature(node);
            if (feature != null) {
                deleteSpatial(feature, skipControl);
            }
        }
        /*
         * Remove the spatial from the scene graph.
         */
        boolean success = spatial.removeFromParent();
        assert success;
    }

    /**
     * Detach this scene's top node from the application's scene graph.
     *
     * @see #attach()
     */
    public void detach() {
        assert !isEnabled();
        assert isAttached();

        int position = bats.getRootNode().detachChild(getNode());
        assert position == 0 : position;

        pov.setEnabled(false);
        viewPort.setEnabled(false);
        world.setEnabled(false);

        assert !isAttached();
    }

    /**
     * Find the scene which contains a specified spatial.
     *
     * @param start which spatial (not null)
     * @return the pre-existing instance
     */
    public static SceneNode find(Spatial start) {
        assert start != null;

        Spatial current = start;
        SceneNode result = current.getControl(SceneNode.class);
        while (result == null) {
            current = current.getParent();
            result = current.getControl(SceneNode.class);
        }
        return result;
    }

    /**
     * Access the application instance which owns this scene.
     *
     * @return the pre-existing instance (not null)
     */
    public Bats getApplication() {
        assert bats != null;
        return bats;
    }

    /**
     * Access the indicators in this scene.
     *
     * @return the pre-existing instance (not null)
     */
    public IndicatorsNode getIndicators() {
        assert indicators != null;
        return indicators;
    }

    /**
     * Access this scene's lights, shadows, sky, and background.
     *
     * @return the pre-existing instance (not null)
     */
    public Lighting getLighting() {
        assert lighting != null;
        return lighting;
    }

    /**
     * Access the name generator for this scene.
     *
     * @return the pre-existing instance (not null)
     */
    public NameGenerator getNameGenerator() {
        assert spatialNameGenerator != null;
        return spatialNameGenerator;
    }

    /**
     * Access the POV in this scene.
     *
     * @return the pre-existing instance (not null)
     */
    public PointOfView getPov() {
        assert pov != null;
        return pov;
    }

    /**
     * Read the resume speed for this scene.
     *
     * @return rate for simulation and animations (standard speed=1, paused=0)
     */
    public float getResumeSpeed() {
        if (resumeSpeed == pauseSpeed) {
            return 0f;
        }
        return resumeSpeed;
    }

    /**
     * Access the scale factor of this scene.
     *
     * @return one world unit (meters per world unit, &gt;0)
     */
    public float getScaleFactor() {
        assert scaleFactor > 0f : scaleFactor;
        return scaleFactor;
    }

    /**
     * Read the total elapsed simulation time for this scene.
     *
     * @return elapsed time (in seconds)
     */
    public float getSimTime() {
        return (float) simTime;
    }

    /**
     * Access the view port for this scene.
     *
     * @return the pre-existing instance (not null)
     */
    public ViewPort getViewPort() {
        assert viewPort != null;
        return viewPort;
    }

    /**
     * Access the water features in this scene.
     *
     * @return the pre-existing instance (not null)
     */
    public Water getWater() {
        assert water != null;
        return water;
    }

    /**
     * Access the world in this scene.
     *
     * @return the pre-existing instance (not null)
     */
    public WorldNode getWorld() {
        assert world != null;
        return world;
    }

    /**
     * Switch this scene from simulation mode to world-building mode.
     */
    public void goBuild() {
        if (!buildModeFlag) {
            world.goBuild();
            buildModeFlag = true;
            simModeFlag = false;
        }
        pov.setKeepVisible(false);
        pov.setMinRange(0.2f);
        pov.setMaxRange(300f);
    }

    /**
     * Switch this scene from world-building mode to simulation mode.
     */
    public void goSimulation() {
        if (!simModeFlag) {
            world.goSimulation();
            buildModeFlag = false;
            simModeFlag = true;
        }
        pov.setKeepVisible(true);
        pov.setMinRange(2f);
        pov.setMaxRange(30f);
    }

    /**
     * Initialize this scene.
     */
    public void initialize() {
        assert !isEnabled();
        assert spatial == null : spatial;

        if (sceneNameGenerator == null) {
            sceneNameGenerator = new NameGenerator();
        }
        String sceneName = sceneNameGenerator.unique(scenarioName);
        /*
         * Create the top node of the scene.
         */
        Node topNode = new Node("scene:" + sceneName);
        topNode.addControl(this);
        /*
         * Instantiate each component of this scene.
         */
        float worldRadius = getWorldRadius(scenarioName);
        indicators = new IndicatorsNode(this, worldRadius);
        lighting = new Lighting(this);
        pov = new PointOfView(this);
        water = new Water(this);
        world = new WorldNode(this, worldRadius);
        /*
         * Initialize the components of this scene.
         */
        indicators.initialize();
        pov.setMoveStep(1f / scaleFactor);
        water.initialize();
        world.initialize();
        float cloudFlattening = getCloudFlattening(scenarioName);
        lighting.initialize(cloudFlattening);

        LightingPreset preset = bats.getInitialLightingPreset();
        lighting.setPreset(preset);
        boolean shadowsEnabled = Bats.getParameters().enableShadows();
        lighting.setShadowsEnabled(shadowsEnabled);

        assert spatial != null;
        assert MySpatial.isOrphan(spatial);
    }

    /**
     * Test whether the scene is attached to the application's scene graph.
     *
     * @return true if attached, false if detached
     */
    final boolean isAttached() {
        Node topNode = (Node) spatial;
        if (topNode == null) {
            return false;
        }
        Node parent = topNode.getParent();
        if (parent == null) {
            return false;
        }
        assert parent == bats.getRootNode() : parent;
        return true;
    }

    /**
     * Test whether this scene is set up for world-building (loading and editing
     * maps) with pickable blocks.
     *
     * @return true if set up for world-building, false otherwise
     */
    public boolean isBuildMode() {
        return buildModeFlag;
    }

    /**
     * Test whether this scene is set up for simulation with non-pickable
     * blocks.
     *
     * @return true if set up for simulation, false otherwise
     */
    public boolean isSimMode() {
        return simModeFlag;
    }

    /**
     * Pick a pickable based on its view port coordinates.
     *
     * @param xy view port coordinates (not null)
     * @param clearPick if true, clear the pick before adding the unit
     */
    public void pickAt(Vector2f xy, boolean clearPick) {
        assert xy != null;

        Spatial pickedSpatial = pov.findPickable(xy);
        if (pickedSpatial == null
                || !SpatialProperties.hasPickType(pickedSpatial)) {
            return;
        }
        if (clearPick) {
            world.getPickables().clearPick();
        }
        /*
         * Toggle the unit into (or out of) the pick.
         */
        SpatialProperties.togglePick(pickedSpatial);
    }

    /**
     * Process an action which has just begun.
     *
     * @param words action string split into an array of words (not null)
     * @return true if the action was handled, false if it wasn't handled
     */
    public boolean processAction(String[] words) {
        String verb = words[0];
        switch (verb) {
            case "print":
                if (words.length == 2) {
                    String what = words[1];
                    if ("scene".equals(what)) {
                        new BatsPrinter().printSubtree(spatial);
                        return true;
                    }
                }
                break;

            case "toggle":
                if (words.length == 2) {
                    String what = words[1];
                    switch (what) {
                        case "pause":
                            /*
                             * Toggle the simulation paused state.
                             */
                            toggleEnabled();
                            return true;
                    }
                }
                break;
        }

        boolean wasHandled = indicators.processAction(words);
        if (wasHandled) {
            return true;
        }
        wasHandled = lighting.processAction(words);
        if (wasHandled) {
            return true;
        }
        wasHandled = pov.processAction(words);
        if (wasHandled) {
            return true;
        }
        wasHandled = world.processAction(words);
        if (wasHandled) {
            return true;
        }
        return false; // not handled
    }

    /**
     * Traverse the node's subtree in depth-first order.
     *
     * @param visitor method invoked on each spatial (not null)
     */
    public void traverse(SceneGraphVisitor visitor) {
        assert visitor != null;

        spatial.depthFirstTraversal(visitor);
    }
    // *************************************************************************
    // AbstractControl methods

    /**
     * Enable or disable this scene. Disabling the scene pauses its simulation
     * and animations temporarily, and re-enabling the scene causes the
     * simulation and animations to resume.
     *
     * @param newState false to disable/pause, true to enable/resume
     */
    @Override
    public void setEnabled(boolean newState) {
        if (isEnabled() && !newState) {
            bats.setSpeed(pauseSpeed);
        } else if (!isEnabled() && newState) {
            bats.setSpeed(resumeSpeed);
        }
        super.setEnabled(newState);
    }

    /**
     * Update this scene. (Invoked once per frame.)
     *
     * @param tpf elapsed time since the previous update (in seconds, &ge;0)
     */
    @Override
    public void update(float tpf) {
        super.update(tpf);
        if (MySpatial.isOrphan(spatial)) {
            return;
        }
        if (firstUpdate) {
            /*
             * Populate this scene for its scenario and the current input mode.
             */
            populate(scenarioName);
            firstUpdate = false;
            if (Bats.isSimulationMode()) {
                goSimulation();
            }
        }
        pov.update();

        if (isEnabled()) {
            float simInterval = FastMath.clamp(tpf, 0f, maxSimInterval);
            simTime += simInterval;
        }
    }
    // *************************************************************************
    // Object methods

    /**
     * Convert this scene to a string.
     *
     * @return a concise description of this scene
     */
    @Override
    public String toString() {
        String attachStatus = isAttached() ? "attached" : "detached";
        String enableStatus = isEnabled() ? "enabled" : "disabled";
        String result = String.format("scenario=%s(%s,%s)",
                MyString.quote(scenarioName), attachStatus, enableStatus);
        return result;
    }
    // *************************************************************************
    // private methods

    /**
     * Remove all of the spatial's controls, possibly except for one.
     *
     * @param spatial (not null)
     * @param skipControl which control to skip removing
     */
    private void deleteSpatialControls(Spatial spatial, Object skipControl) {
        assert spatial != null;
        /*
         * Duplicate the list of controls before removeControl() alters it.
         */
        int count = spatial.getNumControls();
        Object[] controls = new Object[count];
        for (int i = 0; i < count; i++) {
            controls[i] = spatial.getControl(i);
        }
        /*
         * Remove all controls except the skipped one.
         */
        for (int i = 0; i < count; i++) {
            Object control = controls[i];
            if (control != skipControl) {
                BatsControl.setEnabled(control, false);
                Control controlInterface = (Control) control;
                boolean success = spatial.removeControl(controlInterface);
                assert success;
            }
        }
    }

    /**
     * Access the controlled node.
     *
     * @return the pre-existing instance (not null)
     */
    private Node getNode() {
        Node node = (Node) spatial;
        assert node != null;
        return node;
    }

    /**
     * Look up the cloud flattening for a named scenario.
     *
     * @param scenarioName which scenario (castle/default/empty/pose/test)
     * @return oblateness (ellipticity) for the dome with the clouds
     */
    private static float getCloudFlattening(String scenarioName) {
        assert scenarioName != null;

        switch (scenarioName) {
            case "castle":
            case "default":
            case "empty":
            case "test":
                return 0.9f;
            case "pose":
                return 0.8f;
        }
        logger.log(Level.WARNING, "unknown scenario {0}",
                MyString.quote(scenarioName));
        return 0.9f;
    }

    /**
     * Look up the world radius for a named scenario.
     *
     * @param scenarioName which scenario (castle/default/empty/pose/test)
     * @return radius (in meters, &gt;0)
     */
    private static float getWorldRadius(String scenarioName) {
        assert scenarioName != null;

        switch (scenarioName) {
            case "castle":
            case "default":
            case "empty":
            case "test":
                return 250f;
            case "pose":
                return 10f;
        }
        logger.log(Level.WARNING, "unknown scenario {0}",
                MyString.quote(scenarioName));
        return 50f;
    }

    /**
     * Populate the scene for a named scenario.
     *
     * @param scenarioName which scenario (castle/default/empty/pose/test)
     */
    private void populate(String scenarioName) {
        assert scenarioName != null;

        Vector3f povLocation;
        switch (scenarioName) {
            case "castle":
            case "default":
                povLocation = new Vector3f(-3f, 6f, 22f); // meters
                povLocation.divideLocal(scaleFactor);
                pov.setLocation(povLocation);
                world.getMap().populate("castle");
                /*
                 * TODO: add initial units
                 */
                return;

            case "empty":
            case "pose":
                /*
                 * Empty scenario: cameras, lights, and bare ground.
                 */
                povLocation = new Vector3f(-7f, 2f, 0f); // meters
                povLocation.divideLocal(scaleFactor);
                pov.setLocation(povLocation);
                world.getMap().addGround(0f);
                if (scenarioName.equals("pose")) {
                    PoseHud poseHud = (PoseHud) HeadsUpDisplay.getCurrent();
                    String modelName = Bats.getParameters().poseModelName();
                    poseHud.selectModel(modelName);
                }
                return;

            case "test":
                /*
                 * Test scenario: like "castle" but using alternative map.
                 */
                povLocation = new Vector3f(-3f, 6f, 22f); // meters
                povLocation.divideLocal(scaleFactor);
                pov.setLocation(povLocation);
                world.getMap().populate("test");
                return;
        }
        logger.log(Level.WARNING, "unknown scenario {0}",
                MyString.quote(scenarioName));
    }
}
